Skip to content

refactor(sales): put Commerce behind a contract and reach egg stock through IEggStock - #1030

Merged
mforce merged 12 commits into
mainfrom
refactor/854-commerce-contract
Oct 3, 2026
Merged

mforce merged 12 commits into
mainfrom
refactor/854-commerce-contract

Conversation

@mforce

@mforce mforce commented Oct 3, 2026 •

Copy link
Copy Markdown
Owner

Closes #854

Commerce now sits behind a contract: products, conversions, customers, sales orders, allocations and payments. It is the sixth module after Finance (#849), Farm (#851), Flock Management (#852), General Inventory (#855) and Egg Operations (#853). Confirm and void reach egg lots only through a new Egg Operations port, IEggStock, and Commerce no longer sees EggLot. No route, response, status, query, lock, audit row or schema changes. A trace of every SQL statement and transaction event proves that for 30 request scenarios (below).

A real coverage gap on main: nothing pinned the lock order across grades

IEggLotRepository warns that locking each grade's lots in order-line order deadlocks against a void, so a confirm takes one farm-wide FOR UPDATE over every grade on the order. On main that shape was pinned by a comment only: EggLotLockOrderTests uses one grade. With the confirm changed to lock per grade, 156 tests across 15 classes stayed green on the design spike at #1028's head, every lock-order case included.

This PR adds EggLotLockOrderTests.ConfirmSale_TwoGrades_HoldsTheOtherGradesEarlierLotWhileWaitingOnTheLater. The order's first line is a grade whose only lot is canonically later. The test holds that lot and requires the request to already hold the other grade's earlier lot. Under per-grade locking it fails 3 of 3 runs with the request must already hold the earlier lot while it waits on the later one; the other five cases stay green. The order is built through the API, one request per line, so the lines keep their order. A precondition asserts that order, and it caught an earlier seed that saved both lines in one batch and got them back in either order.

Checkpoint decisions (owner-approved)

  • ICommerceModule for adapters, one narrow port for peers ([C] #514 slice 10: Flock Management contract, eligibility and mortality ports #852's rule). The product, customer, sale and payment endpoints and both seeders call ICommerceModule. Farm's UpdateFarmSettingsHandler and Access's SetStepperUnitHandler read packed-unit conversions through IEggUnitConversionLookup, which forwards to the same GetByUnitAsync. CommerceModule only forwards to the existing handlers and five repositories; no handler body changes except confirm and void below.
  • The stock port lives in Egg Operations. IEggStock.LockForSaleAsync runs the same single FOR UPDATE and returns an IEggStockReservation. Its Plan is the FIFO planner, moved from Commerce, over the locked lots; it is pure, so the Configurable Worker sales allocation scope by flock assignment #612 assigned-flocks-first retry plans twice over the same rows with no second lock. DrawAsync calls EggLot.Allocate on the locked instance (a refusal throws) and writes the Sale movement. RestoreAsync is the void's lock, AllocationSourceMissing check, Restore and Void movement. The port never saves, like IMortalityLedger. Commerce keeps the order lock, the Configurable Worker sales allocation scope by flock assignment #612 policy and refusals, the allocation rows and their ids, and the audit write. Movement reference types are passed in, so Egg Operations names no Commerce type.
  • One in-memory step moved. A draw's SalesOrderAllocation is created before Allocate runs, because the Sale movement needs its id. Nothing reaches the database before the single save, and EF orders that batch itself; the SQL trace is byte-identical.
  • Grade reads. CreateProductHandler, UpdateProductHandler, AddOrderItemHandler and confirm's two refusal names read through IEggGradeLookup.GetAsync, which runs the same tracked GetByIdAsync. The grade copy moved into EggGradeLookup so the module and the lookup share one.
  • The currency probe is inverted, and both of its deleteWhen #854 rows are deleted. Farm owns ICurrencyBoundRowSource. The seven repositories that own a probed table implement it with exactly the probe's query for that table, registered in the order the probe read them. CurrencyBoundRowProbe moved into Farm and asks the sources in that order, stopping at the first yes. A read on each module's contract was rejected: Farm would resolve ICommerceModule and its handlers on every IFarmModule use, and gain edges that close cycles with Finance → Farm and General Inventory → Farm.
  • The SQL trace stays a script, re-run and summarised here, not a committed test.

Rationale: docs/decisions/854-commerce-contract.md. AGENTS.md's #849 paragraph now names Commerce and the IEggStock rule; docs/architecture.md's egg loop names the port.

Reach

Before (main d6dbd47, placeholder contract) After
Adapter crossings into Commerce through a non-contract type 57 (37 types, 31 adapter rows) 0
Adapter rows reaching Commerce (matrix) A (31) A (31)
#850 reads of Commerce tables from outside it, unregistered 44 0
...admitted by the 6 listed repository implementations 38 (35 + the 3 Commerce currency sources)
...registered compatibilityExceptions rows (Platform, deleteWhen #858) 8 seeder methods
CurrencyBoundRowProbe rows (deleteWhen #854) 2 0
compatibilityExceptions rows in total 31 37
Commerce -> EggOperations edge W, 8 symbols W, 5 symbols (the 3 planner types moved)
owners.EggOperations.contract 26 types 31 (+ IEggStock, IEggStockReservation, SaleDemandLine, PlannedEggLotDraw, SaleAllocationPlan)
owners.Commerce.contract none 34 types

The regenerated coupling-matrix.md changes only the Commerce -> EggOperations cell, W (8) → W (5). No edge was added or removed.

Change map

BEFORE                                                AFTER
Product/Customer/Sale/Payment endpoints (29 rows),    endpoints + both seeders
DemoDataSeeder, SimulationDataSeeder (2 rows)          └─► ICommerceModule ◄── new front door
 ├─► 15 handlers                                            └─► CommerceModule (only forwards)
 ├─► 5 repositories                                              ├─► 15 handlers
 └─► SalesOrder / Payment / Customer / Product                   ├─► 5 repositories
                                                                 └─► returns 7 *Details records
ConfirmSaleHandler                                    ConfirmSaleHandler
 ├─► IEggLotRepository (FOR UPDATE)                    ├─► IEggStock.LockForSaleAsync ─► IEggStockReservation
 ├─► IEggInventoryMovementRepository                   │      .Plan (×1 or ×2) / .DrawAsync
 ├─► IEggGradeRepository                               └─► IEggGradeLookup.GetAsync
 └─► SaleAllocationPlanner(EggLot) [Commerce]          [Egg Ops] EggStock ─► IEggLotRepository,
VoidSaleHandler                                                IEggInventoryMovementRepository,
 ├─► IEggLotRepository (FOR UPDATE by id)                      SaleAllocationPlanner (moved here)
 └─► IEggInventoryMovementRepository                  VoidSaleHandler ─► IEggStock.RestoreAsync
Create/UpdateProduct, AddOrderItem ─► IEggGradeRepository    ─► IEggGradeLookup.GetAsync
UpdateFarmSettingsHandler (Farm),                     ─► IEggUnitConversionLookup ◄── new peer port
SetStepperUnitHandler (Access) ─► IEggUnitConversionRepository
FarmModule, UpdateFarmSettingsHandler                 FarmModule, UpdateFarmSettingsHandler
 └─► ICurrencyBoundRowProbe                            └─► ICurrencyBoundRowProbe
      ◄── CurrencyBoundRowProbe (Infrastructure,             ◄── CurrencyBoundRowProbe (Farm, Application)
          reads 7 tables of 3 modules; 2 #850 rows)               └─► IEnumerable<ICurrencyBoundRowSource>
                                                                       ◄── SalesOrder, Payment, Expense, Product,
                                                                           InventoryLot, FeedUsage, InventoryItem
                                                                           repositories, in the old probe order

module-ledger.json
  owners.Commerce.contract             = 2 interfaces, 7 records, 4 existing DTOs, 12 commands,
                                         2 responses, 7 Domain enums (34 types)
  owners.Commerce.implementations      = Customer, EggUnitConversion, Payment, Product,
                                         SalesOrderAllocation, SalesOrder repositories
  owners.EggOperations.contract        + 5 stock-port types (31)
  compatibilityExceptions              - 2 probe rows, + 8 seeder rows (Platform, #858)
  edges                                Commerce -> EggOperations: 3 planner symbols removed, reason names IEggStock
coupling-matrix.md                     regenerated: Commerce -> EggOperations W (8) -> W (5)

Files this PR shares with open slices end in this shape:
  ConfirmSaleHandler (#857): the fresh role read and the assignment read are unchanged and still
    inside the transaction, after the order lock and before the lot lock.
  SetStepperUnitHandler (#857): takes IEggUnitConversionLookup; AccountProvisioner is untouched.
  DemoDataSeeder, SimulationDataSeeder (#858): constructors take ICommerceModule; 8 db.* reads are rows.
  module-ledger.json, coupling-matrix.md, AGENTS.md, DI registrations.

Verification

Counts were measured on this machine, before and after.

Project main d6dbd47 This branch 4036702
Domain 495 495
Application 675 681 (+5 CommerceModuleTests, +1 GradeLookup_GetCopiesEveryField)
AppHost 10 10
Integration 1888 1893 (+1 two-grade lock order, +4 ConfirmSaleAtomicityTests)

dotnet build Cluckwork.sln is clean with warnings as errors, which also enforces the #985 style gate.

Test edits. One existing test file changed its input: SaleAllocationPlannerTests builds SaleDemandLines instead of a SalesOrder, because the planner moved to Egg Operations and no longer takes order items. Its 7 assertions are byte-identical, and reversing FIFO order in the planner still turns WholeOrderFits_ProducesOneDrawPerLot_InFifoOrder red. EggLotLockOrderTests and EggOperationsModuleTests only gained a test each (git diff --numstat: 40 and 7 lines added, 0 removed). Every other test file is unedited. EggLotLockSqlTests still finds all three FOR UPDATE statements, because the SQL never left EggLotRepository.

EF model digest. The SHA-256 of the design-time Model.ToDebugString(LongDefault) is 45F550E426163CEBEFF36E509CFC6E53B36D26D3D89C72E0F728855BCC8FB9AE on main d6dbd47 and on this head. The two dumps are byte-identical.

SQL trace (re-run script, not committed). A throwaway test adds a DbCommandInterceptor and a DbTransactionInterceptor through ConfigureDbContext and records every statement and transaction event of a request. Response bodies are recorded too, with ids and timestamps normalised. Repeated runs on the base were identical, so the trace has no noise. This head against main:

Scenario SQL and transaction events
Confirm success (2 grades, 3 lots) byte-identical
Void success byte-identical
Short confirm (owner, 2 lines, second short) byte-identical
Void refused by the payment gate byte-identical
Currency probe, empty farm (all 7 reads) and farm with one expense (stops after 3) byte-identical, same canChangeCurrency
24 Commerce routes: create, update, list and read customers, products and conversions; draft, edit, confirm, read and list orders, including unpaid=true; record, list and void payments; balances; cancel SQL and response bodies byte-identical

All 54 files match. The script also catches a wrong port: locking per grade adds a second lock statement, and saving inside DrawAsync splits the save into separate batches.

Invariants. Every suite passed in the full run at 4036702; none was edited except as stated above.

Invariant Tests (unedited)
Parallel confirm, double confirm, no oversell EggLotConcurrencyTests (2), SalesDiscountReasonTests.ParallelConfirms_OnlyOneWins_AndVersionMovesOnce, SalesDiscountCeilingTests.TwoManagersConfirmingOneOverCeilingOrderConcurrently_AllocateItExactlyOnce
Parallel void, void racing confirm VoidSaleTests.ParallelVoids_ExactlyOneWins_StockRestoredOnce, VoidRacingConfirm_OnSameLots_NeverLosesOrDuplicatesStock
Lock order (ProductionDate, Id) EggLotLockOrderTests (5 existing cases + the new two-grade case), EggLotLockSqlTests
One lock, two plans over the same rows (#612) SaleAllocationPolicyTests (13, incl. ConfirmSale_RestrictedWorkerAssignedRetry_IssuesExactlyOneFifoQuery, whose counting IEggLotRepository decorator still sees the port's call)
Insufficient-stock rollback EggLedgerTests.FailedOperations_WriteNoMovements, SaleAllocationPolicyTests.*_AppliesNothing, SaleAllocationPlannerTests.InsufficientStock_ReportsTheShortGradeAndRemainder_AndProducesNoDraws
No partial state at 4 fault points ConfirmSaleAtomicityTests (4, new)
FIFO ordering VoidSaleTests.Void_RestoresExactSourceLots_AndPreservesFifo, EggLedgerTests.MultiLotSale_WritesOneMovementPerLot_NewestFirst_LotsPage, SimulationSeederTests.SimulationSeed_ConfirmedOrders_DepleteEggLotsFifoFromASharedOldLotPool, SaleAllocationPlannerTests
Provenance release on void VoidSaleTests (allocation rows released, never deleted)
Payment gate on void PaymentsTests.VoidOrder_WithPayments_RefusedUntilPaymentsVoided, AuditTests.MidTransactionFailure_WritesNothing_EventualVoidWritesOne
Order and payment Version races CustomerAndOrderTests.ParallelAddItems_TotalMatchesPersistedItems, Customer_Update_StaleBaseVersion_ExactlyOneWinnerOverHttp, PaymentsTests.ParallelPayments_CannotOvershootTheTotal, ParallelVoids_SameBaseVersion_ExactlyOneWins
Product snapshots on order lines SalesProductTests
Tenant isolation TenantIsolationTests.ConfirmSale_ForOtherAccountsOrder_Returns404, CustomerAndOrderTests.*Foreign*, EggLedgerTests.LotMovements_AreTenantIsolated_ForeignLotLooksNonexistent
Retry boundary (#269) TransientDbResilienceTests.TransientFailure_InsideAWrappedWrite_IsNotReplayed_ButTheClientsOwnRetrySucceeds; the port opens no transaction or execution strategy
Currency lock (#123, #162) FarmSettingsTests (54, with a refusal for each probed table that can exist alone), CurrencyLockRaceTests, CurrencyLockSerializationTests, FarmModuleTests
Audit rows and provenance AuditProvenanceTests, AuditTests, SalesOrderAuditPayloadTests
Seeders DemoSeedTests, SimulationSeederTests
EF model digest identical, above

The stock, sales, seed and currency set above ran as one filter of 384 tests at each step and passed every time.

Mutations. Each was applied to a clean build that compiled, run, then reverted and rebuilt:

Mutation Result
IEggStock locks each grade separately, in line order red 3/3: EggLotLockOrderTests.ConfirmSale_TwoGrades_… (the request must already hold the earlier lot…); the other 5 lock-order cases stay green
FIFO reversed in SaleAllocationPlanner red: SaleAllocationPlannerTests.WholeOrderFits_ProducesOneDrawPerLot_InFifoOrder (first draw from the newer lot)
Plan ignores the assigned-flock filter red: SaleAllocationPolicyTests.DefaultPolicy_RestrictedWorker_AssignedInsufficient_FarmWideSufficient_… and ConfirmSale_RestrictedWorkerAssignedRetry_IssuesExactlyOneFifoQuery (OK where 422 is expected)
IEggStockReservation exposes the locked EggLots red: ModuleContractRealAssemblyTests
A member returning SalesOrder on ICommerceModule red: ModuleContractRealAssemblyTests (LeakAsync exposes Cluckwork.Domain.Sales.SalesOrder)
ISalesOrderRepository orders added back to SaleEndpoints.GetSalesOrder red: AdapterReachRealTreeTests (contract bypass … GetSalesOrder -> Commerce through …ISalesOrderRepository)
PaymentRepository removed from implementations red: CompatibilityExceptionRealTreeTests, every PaymentRepository read undeclared, its currency-source read included
ProductRepository not registered as a currency source red: FarmSettingsTests.CurrencyChange_AfterAPricedProductExists_IsRefused
Email ↔ Address in the customer copy red: CommerceModuleTests.CustomerReads_…
Quantity ↔ QuantityBase in the order-line copy red: CommerceModuleTests.OrderReads_…
ReferenceNumber ↔ Note in the payment copy red: CommerceModuleTests.PaymentReads_…
Confirm without its transaction red, all 4 rollback cases, at the port's check (IEggStock runs only inside a transaction); with the port's checks also off, at the test's the confirm runs outside a transaction (round 1)
Confirm saves after each draw (transaction kept) red, all 4: a drawn lot was already saved before the fault (round 1)
A reservation used after its transaction, or with none red: EggStockTransactionTests (round 1)
ConfirmSaleHandler takes IEggLotRepository again red: PeerContractRealTreeTests; the other 338 architecture tests stay green (round 1)

Since review round 1, ConfirmSaleAtomicityTests also checks at each fault that the confirm is in a transaction and has saved no draw, so each rule fails on its own. See the round-1 comment for the rest of that round.

Not tested, and why. EggLot.AllocationSourceMissing moved verbatim into RestoreAsync with no test. It cannot be reached: FK_SalesOrderAllocations_EggLots_EggLotId stops a drawn lot being deleted, and void is AdminOnly, so the flock scope never hides one.

Cleanup. pstack:deslop ran on the branch diff before opening. It reflowed one confirm comment that the rewrite had spliced onto a single long line, and removed a comment in EggStock.DrawAsync that repeated the interface's reason for throwing. Building with IDE0005 enabled then found three unused usings this branch introduced (ProductEndpoints, CustomerEndpoints, CommerceModule); they are gone. The ten other IDE0005 hits in touched files were already unused on main, so they are left alone.

Nothing user-visible changes, so this PR has no screenshots.

mforce added 6 commits October 3, 2026 04:32
Confirm and void reach egg lots through Egg Operations' IEggStock port.
LockForSaleAsync runs the same single FOR UPDATE; the reservation plans
over the locked lots (the FIFO planner moves to Egg Operations) and draws
one planned quantity with its Sale movement. RestoreAsync locks, restores
and writes the Void movements. The port never saves.

Commerce's grade reads go through IEggGradeLookup.GetAsync, which runs
the same GetByIdAsync query.

EggLotLockOrderTests gains a two-grade confirm case: before it, locking
per grade in line order left every test green.

Refs #854
… amounts

Farm's CurrencyBoundRowProbe no longer reads Commerce, Finance and General
Inventory tables itself. Each repository that owns a probed table
implements Farm's ICurrencyBoundRowSource with the probe's query for that
table, and the probe asks them in registration order, which is the order
it read them in before. Both of the probe's compatibility-exception rows
(deleteWhen #854) are gone.

Refs #854
Product, customer, sale and payment endpoints and both seeders reach
Commerce only through ICommerceModule, which forwards to the existing
handlers and repositories and copies reads into *Details records. Farm's
settings handler and Access's stepper-unit handler read packed-unit
conversions through the new IEggUnitConversionLookup port.

The ledger lists the 34 contract types and six repository
implementations. The eight seeder reads of Commerce tables are
compatibility exceptions for #858.

Refs #854
…g commit

CommerceModuleTests pins every Details copy field by field. Swapping two
same-typed fields in the customer, order-line or payment copy turns it red.

ConfirmSaleAtomicityTests faults a confirm after a lot allocation, after
its Sale movement, after the allocation rows and after the order is
confirmed, and finds nothing persisted. Running confirm without its
transaction while saving after each draw turns all four red.

Refs #854
@mforce

mforce commented Oct 3, 2026

Copy link
Copy Markdown
Owner Author

Independent review: Claude Opus 5.5 (xhigh), at head 4036702d. One of two parallel reviews (Codex Astra is the other); posted by the coordinator. Evidence paths named below are local to the review host.

PR #1030 review: Commerce contract and the IEggStock seam (Opus, independent)

  • Head: 4036702d7526780b35bd506095edfc35d9133af7. Base: main d6dbd47d.
  • Checkout: a detached worktree at /home/mforce/.cluckwork-slices/review-1030-opus, removed after the review.
  • Method:
    • I read the confirm and void handlers line by line against main, and read the port, the planner move, the probe inversion, the peer ports, the endpoints, the seeders, the ledger, the matrix and the docs.
    • I formed my view before reading the PR body. There were no PR comments yet.
    • I ran targeted test classes and four mutations, and reverted each one.
    • CI on the head is all green.

Verdict on the core question

I found no product defect. Confirm and void behave the same as on main, step by step:

  • Transaction boundary, save and audit. ExecuteInTransactionAsync is unchanged. There is still one SaveChanges, in UnitOfWork, after the lambda returns true. There is one audit write per path, inside the transaction.
  • Lock statement. There is one FOR UPDATE over every grade, farm-wide, ORDER BY "ProductionDate", "Id" (EggStock.cs:11 → EggLotRepository.cs:43-60, SQL untouched). It runs at the same point: after the Account FOR SHARE, the order FOR UPDATE, the role re-read, the ceiling check and the assignment read.
  • Two plans (Configurable Worker sales allocation scope by flock assignment #612). Both run over the same locked instances: Plan(demand, assignedFlockIds), then Plan(demand). There is no second query. The filter is the same Contains(l.FlockId).
  • When Allocate runs. It runs only after a complete plan, on the same tracked instance, and a refusal throws InvalidOperationException with the same message as before. So the 500 and rollback behaviour is unchanged.
  • Allocation created first. SalesOrderAllocation is now constructed before Allocate. It is untracked until AddRangeAsync, so the change-tracker insertion order and the SQL batch are unchanged.
  • Void.
    • The payment gate, order.Void, the NoAllocationRecords refusal and the per-lot sum are unchanged.
    • RestoreAsync returns a Result, as the inline code did. The handler maps its error through unchanged.
    • Restore iterates the locked lots in canonical order and writes the Void movement with the same reference type, id and reason.
    • The MarkReleased provenance release follows, as before.
  • Retry boundary (Add transient-DB resilience (EnableRetryOnFailure) on the request path #269). The port opens no transaction, creates no scope or execution strategy, and never calls SaveChanges. I checked EggStock, EggLotRepository and EggInventoryMovementRepository.
  • [C] #514 slice 11: Egg Operations contract and the opaque stock seam #853's six seam constraints. One lock, two plans, apply only the chosen plan, Commerce mints the allocation id, void restores by lot, no save. All six hold.
  • No EggLot in the contract surface. No contract type exposes EggLot. IEggStock, IEggStockReservation, SaleAllocationPlan, PlannedEggLotDraw and SaleDemandLine carry only ids, ints, dates, strings and Result.
  • Currency probe.
    • The queries and their order are the same: SalesOrders, Payments, Expenses, priced Products, InventoryLots, FeedUsages, InventoryItems with a default cost.
    • It still short-circuits on the first yes, on the same scoped AppDbContext and so in the same transaction.
    • MS.DI resolves IEnumerable<T> in registration order, and ICurrencyBoundRowSource has no other registration.
    • Order cannot affect the answer. The answer is an OR over side-effect-free reads under the Close the §4.6 currency-lock race properly (shared lock on the account row across money-writing handlers) #162 Account lock. Order only changes how many queries run.
  • Peer ports. IEggGradeLookup.GetAsync and IEggUnitConversionLookup.GetByUnitAsync forward to the same tracked FirstOrDefaultAsync, under the same tenant filter, and copy every field.

Findings

None of these findings is a product defect.

F1: ConfirmSaleAtomicityTests is red only when both rules break; a cheap change makes each rule fail alone

  • Severity: P3 (test quality). Status: CONFIRMED.
  • Location: tests/Cluckwork.Api.IntegrationTests/ConfirmSaleAtomicityTests.cs:82.
  • Defect: the test checks only the persisted outcome, so it turns red only when the transaction AND the never-save rule both break.
  • Failure scenario: a later change adds await unitOfWork.SaveChangesAsync(transactionCt) after each DrawAsync. Today that is harmless, because the transaction rolls it back. But the rule AGENTS.md now says to keep is no longer pinned. A second, unrelated change that removes the handler's transaction then breaks atomicity on the seeder path with no warning between the two changes. The same holds in reverse.
  • Evidence:
    • Mutation B (PR's): a save after each draw → all 4 cases stay green. Reproduced.
    • I prototyped two checks at the fault point:
      • db.Database.CurrentTransaction is not null;
      • no tracked EggLot is Unchanged (that is, no draw was flushed early).
    • The prototype is a throwaway diff at /home/mforce/.cluckwork-slices/reviews/1030-4036702d-opus-atomicity-prototype.diff. It uses a static field; the real version should pass the context into the decorators.
    • Control (no mutation): 4/4 green.
    • Save after each draw alone: 4/4 red with a drawn lot was already saved before the fault.
    • No transaction alone (one save at the end): 4/4 red with the confirm runs outside a transaction.
  • Recommendation: keep the test. It is a correct outcome test, and the PR is candid about what it can and cannot see. Add the two invariant checks, so each rule has its own red and "keep both" in AGENTS.md is enforced.
  • Coverage note: the test runs outside HTTP, so it covers the owned-transaction path (seeders) only, not the path that joins IdempotencyMiddleware's transaction.

F2: "Commerce never sees EggLot" is stated as a rule, but nothing enforces it

  • Severity: P3 (doc honesty). Status: CONFIRMED.
  • Location: AGENTS.md:84 (the [C] #514 slice 12: Commerce contract, consuming the stock seam #854 sentence), module-ledger.json:227 (edge reason), docs/architecture.md.
  • Defect: the AGENTS.md sentence reads as an enforced fact, but no test fails when Commerce reaches EggLot again.
  • Failure scenario: a later change has ConfirmSaleHandler inject IEggLotRepository and plan with SaleAllocationPlanner.Plan(..., EggLot list) directly. Every architecture guard stays green, because the handler is already a declared symbol on the Commerce -> EggOperations edge and peers are not walked by type.
  • Evidence:
    • Mutation D (mine, not in the PR's table): add IEggLotRepository eggLots to ConfirmSaleHandler, call GetAvailableFifoLockedAsync and pass the result to SaleAllocationPlanner.Plan.
    • Result: dotnet test tests/Cluckwork.Application.Tests --filter FullyQualifiedName~Architecture gave 338/338 green.
  • What the docs already say: the 854 record says peers are unchecked, but its example is IEggUnitConversionRepository. [C] #514 slice 10: Flock Management contract, eligibility and mortality ports #852's AGENTS sentence carries the explicit caveat; [C] #514 slice 12: Commerce contract, consuming the stock seam #854's does not.
  • Recommendation: append "Nothing checks this: a ConfirmSaleHandler that injects IEggLotRepository again stays green."

F3: Two ledger edge reasons still name IEggUnitConversionRepository

  • Severity: P3 (ledger honesty). Status: CONFIRMED.
  • Location: tests/Cluckwork.Application.Tests/Architecture/Data/module-ledger.json:167 (Access → Commerce) and :269 (Farm → Commerce).
  • Defect: both reasons still say the handler "resolves ... through Commerce's IEggUnitConversionRepository". SetStepperUnitHandler and UpdateFarmSettingsHandler now take IEggUnitConversionLookup.
  • Failure scenario: the Modular-monolith architecture design + implementation plan (from PR #423) #514 Track B and [C] #514 slice 15: Access contract — security hold point, runs last #857 slices plan from these reasons. They would look for a repository dependency that no longer exists, and miss that the lookup is now the seam.
  • Evidence:
    • git diff d6dbd47d 4036702d -- .../SetStepperUnitHandler.cs .../UpdateFarmSettingsHandler.cs shows both switched to the lookup.
    • The two reason strings are unchanged in the ledger diff. The PR rewrote only the Commerce -> EggOperations reason.
    • No guard reads reason text, so CI cannot see this.

F4: IEggStockReservation.Plan is pure only until the first DrawAsync

  • Severity: P3 (contract wording). Status: CONFIRMED by reading.
  • Location: src/Cluckwork.Application/Features/EggLots/IEggStock.cs:23-25, docs/decisions/854-commerce-contract.md:56-57.
  • Defect: the comment says Plan "plans the same way every time". In fact Plan seeds remainingByLot from each locked lot's live QuantityAvailable (SaleAllocationPlanner.cs:41), and DrawAsync mutates those same tracked instances.
  • Failure scenario: a future caller plans, draws, then plans again, for example a partial-confirm retry or a second order in one reservation. The second plan silently differs from the first.
  • Impact today: none. ConfirmSaleHandler never plans after drawing.
  • Recommendation: either reword to "the same way every time until a DrawAsync", or snapshot quantities at lock time.

Nits

None of these is a defect. The draw-per-call shape is owner-approved and kept.

  • DrawAsync accepts any draw (EggStock.cs:47). It does not check that the draw came from a complete plan of this reservation, or that it is applied only once. Drawing twice from a lot with enough stock double-allocates silently. The old inline code had the same freedom. Optional hardening: the reservation records applied draws and refuses a repeat.
  • Nothing ties a reservation to its transaction. A reservation used after commit mutates tracked lots without the row lock, and only the Version token stops a lost write. The interface comment "Call it once per confirm" (IEggStock.cs:9) is also unenforced. Only one caller exists, and it holds the reservation in a local.
  • Duplicate bodies. EggOperationsModule.GetGradeAsync (EggOperationsModule.cs:62) and EggGradeLookup.GetAsync now have identical bodies. One could delegate to the other.
  • Cost of resolving CommerceModule (CommerceModule.cs:22). It constructs all 15 handlers, including IIdentityProvider's UserManager graph through ConfirmSaleHandler, on every Commerce read endpoint. On main, those endpoints resolved one repository. This is the same pattern as the earlier module contracts and affects adapters only.
  • A currency source registers by hand (CluckworkFeatureServiceCollectionExtensions.cs:148-154). A repository that implements ICurrencyBoundRowSource but is not registered is skipped silently. Five of the seven sources have their own FarmSettingsTests refusal; Payments and FeedUsages cannot exist alone. The exposure is the same as the old hand-written OR chain. A test asserting "implementors == registrations" would close it.

Reproduced and verified

Check Result
Build (the integration and application test projects, which build all of src/, warnings as errors, #985 style gate) clean
Clean head: EggLotLockOrderTests, ConfirmSaleAtomicityTests, VoidSaleTests, EggLotConcurrencyTests, SaleAllocationPolicyTests 32/32 green
Clean head: Application Architecture (ledger, matrix regeneration, adapter reach, contract walk, compatibility exceptions), CommerceModuleTests, SaleAllocationPlannerTests, EggOperationsModuleTests 359/359 green
Two-grade lock test, repeated on the clean head 5/5 green; deterministic, because LockRows sits above Sort and the holder never commits before the assertion
Mutation A (PR's): LockForSaleAsync locks each grade in line order; the reservation is kept sorted canonically, so only the lock order differs two-grade case red, other 5 lock-order cases green
Mutation B (PR's): save after each draw, transaction kept atomicity 4/4 green (matches the PR)
Mutation C (mine, against the F1 prototype): no handler transaction, one save at the end prototype 4/4 red; original test green per the PR
Mutation D (mine): ConfirmSaleHandler re-injects IEggLotRepository and plans over EggLot Architecture 338/338 green (F2)
AllocationSourceMissing claimed unreachable holds: FK_SalesOrderAllocations_EggLots_EggLotId ... ON DELETE RESTRICT, no caller of EggLotRepository.Remove, void is AdminOnly, and Owner and Manager are unrestricted in FlockScope

Not reproduced: the 54-file SQL trace. The script is not committed. My reading agrees with it: the lock SQL is untouched, the probe queries are verbatim, and no tracked entity changes order.

All mutations are reverted. The worktree was clean before removal.

Product defects: none. Findings: 4 × P3, all docs or tests.

Verdict: merge. F1–F4 are cheap follow-ups, and F3 and F2 are one-line text fixes that could ride along.

@mforce

mforce commented Oct 3, 2026

Copy link
Copy Markdown
Owner Author

Review of record: Codex gpt-6-astra (xhigh), round 1, at head 4036702d. One of two parallel reviews (Opus is the other); posted by the coordinator. Evidence paths named below are local to the review host.

PR #1030 review by Astra

Reviewed 4036702d7526780b35bd506095edfc35d9133af7 against d6dbd47d. Review performed independently, without delegation or contact with the other reviewer.

One confirmed P2 contract defect. No product defect was demonstrated in the current routes or seeders.

  1. P2, CONFIRMED. The stock reservation remains usable after its locking transaction ends.

    Location: src/Cluckwork.Application/Features/EggLots/EggStock.cs:47, with construction at lines 8–11 and retained state at lines 37–45.

    Reservation retains tracked EggLot instances and the movement repository, but captures no transaction identity or lifetime; neither Plan nor DrawAsync rejects an expired reservation.

    Concrete scenario: a non-HTTP caller retains the reservation returned inside ExecuteInTransactionAsync, lets that transaction commit, then plans and draws in the same service scope. DrawAsync succeeds after PostgreSQL has released the locks. A subsequent SaveChangesAsync persists the stock reduction and Sale movement independently of the transaction that was supposed to protect them. A later failure in the caller cannot roll that committed work back.

    Evidence: the throwaway real-Postgres test Scratch1030ReviewTests.ReservationAfterCommit_StillPlansAndPersistsAnUnlockedDraw verifies Database.CurrentTransaction == null, then successfully plans and draws ten eggs. A fresh context sees availability fall from 30 to 20 and one Sale movement. This diagnostic deliberately asserts that the forbidden usage succeeds. It passed on the unmodified head. Source and TRX evidence are retained beside this report.

    Product impact: not a demonstrated current product defect. ConfirmSaleHandler keeps its reservation local to the transaction, and I found no production caller retaining it. It does violate this review's explicit requirement that the reservation be unusable after its transaction. Scoped DI does not enforce that requirement: a scope can outlive a transaction. A disposed request's DbContext prevents later persistence through that context, but does not invalidate the reservation's in-memory planner.

    Required change: bind the reservation to its originating transaction and reject use after it ends, including use inside a different transaction. Keep persistence types out of the contract. Add a real test requiring rejection of the demonstrated escape.

The confirm/void comparison found no other behavioral change:

  • Confirm preserves the account FOR SHARE, order FOR UPDATE, fresh role/ceiling/assignment checks, and one farm-wide lot lock over all grades in (ProductionDate, Id) order. Both planning passes use the same reservation. The planner copies available quantities and mutates only its local dictionary; assigned-flock selection retains the locked rows' order.
  • The earlier allocation construction is safe for the actual planner output. Every draw quantity is positive because both remaining demand and available stock are positive before Math.Min. SalesOrderAllocation.Create only checks positivity, generates an ID and constructs an untracked object. AddRangeAsync still happens after all draws and order confirmation. An Allocate refusal still throws the same invariant exception before the final save; the extra local object cannot persist. Fresh SQL traces also matched.
  • Void retains the payment gate under the order lock, groups pending allocations by lot, restores in canonical order, marks provenance released, and writes one audit row. Sale movements reference the allocation ID; Void movements reference the order ID and reason, exactly as [C] #514 slice 11: Egg Operations contract and the opaque stock seam #853 specifies. Void movements should not be changed to reference allocation IDs.
  • Both handlers retain one business save through UnitOfWork. HTTP calls join the middleware's request transaction; direct calls own their transaction. AmbientTransaction and SingleAttemptExecution are unchanged. The seam neither saves nor starts a retry strategy. A separate diagnostic forced a contradictory second draw and confirmed the exception left the earlier draw and movement unpersisted.

Independent mutation results, each compiled and reverted:

Mutation Result
Lock each grade separately in line order New two-grade lock test failed; five existing lock tests passed
Ignore the assigned-flock filter in Plan Three policy tests failed; ten passed
Reverse FIFO traversal FIFO planner test failed; six other planner tests passed
Save inside DrawAsync, transaction retained All four atomicity cases passed
Remove confirm transaction, final save retained All four atomicity cases passed
Remove transaction and save inside DrawAsync All four atomicity cases failed; fresh snapshots showed persisted lot reductions and Sale movements
Inject ISalesOrderRepository into SaleEndpoints.GetSalesOrder Adapter guard failed with the specific contract bypass
Expose SalesOrder, Product, Customer, or Payment through ICommerceModule, separately Contract guard failed for every mutation
Expose EggLot through IEggStockReservation Contract guard failed both directly and through IEggStock.LockForSaleAsync
Remove PaymentRepository from Commerce implementations Compatibility guard failed on its reads, including its currency-source method

The four-point atomicity test is meaningful as a check for partial persistence under those faults. It is not an independent guard for the explicit transaction or the no-save rule. All four injected faults occur before the normal final save, so the unmodified test does not exercise rollback of already-issued business writes. The combined mutation supplies those writes and proves the assertions detect them. The PR describes this limitation accurately; I do not report it as a separate defect. Detecting an added seam save depends on the reviewed code and the separately rerun SQL trace.

The currency probe preserves the seven predicates, their order and short-circuit behavior. A real DI resolution returned SalesOrder, Payment, Expense, Product, InventoryLot, FeedUsage and InventoryItem repositories in that order. These explicit registrations provide deterministic enumeration. The empty-farm trace runs all seven reads; the expense-farm trace stops after the third. Both deleteWhen #854 exceptions are gone.

The contract and ledger changes match the code: six Commerce implementations, eight new seeder exceptions assigned to #858, and the Commerce-to-EggOperations edge reduced from eight symbols to five because the planner types moved. No EggLot reaches the Commerce contract. I regenerated the coupling matrix; it remained byte-identical to the committed file. Endpoint response copies preserve the existing fields, including quantities, list-price basis, money denomination, versions where previously exposed, and discount reasons. The new decision record accurately describes the implemented flow and the disclosed guard limits.

I reran the PR's throwaway trace code on both commits in the assigned checkout. All four confirm/void scenarios matched byte-for-byte, as did the 24 route scenarios' SQL and normalized bodies. Across the complete output, 52 files were literally identical. The other two differed only in generated farm IDs in currency-probe response headers; their SQL and normalized headers matched. The recorder omits SQL parameter values and normalizes response IDs/timestamps, so this proves the recorded query shapes and normalized examples, not every possible input.

Verification: the final solution build passed with zero warnings/errors, including #985's style gate. All 132 selected existing integration cases, 29 selected Application cases, three additional diagnostic cases, and the three trace cases on each commit passed. Application guards passed again after mutation restoration. No full integration suite was run. I did not independently rerun the SPA suite or EF-model digest. The checkout is clean and restored to the reviewed head; no commits, pushes or GitHub comments were made.

Evidence: 1030-4036702d-astra-evidence/ beside this report contains diagnostic source, the mutation runner and logs, TRX results, and both sets of traces.

Nits: none.

Verdict: request changes for the reservation lifetime contract; no current product regression demonstrated.

mforce added 4 commits October 3, 2026 05:17
…cked it

IEggStock refuses to run without a transaction, and a reservation
remembers the transaction that locked its lots and refuses to plan or
draw once another transaction, or none, is current. It reads the
transaction id through a new Platform port, ICurrentTransaction, so no
persistence type reaches Application. EggStockTransactionTests keeps a
reservation past its commit and requires both refusals.

ConfirmSaleAtomicityTests now checks, at each fault, that the confirm is
still in a transaction and that no draw was saved yet, so each rule fails
on its own. IEggStockReservation.Plan's comment no longer calls it pure.

Refs #854
…ts contract

PeerContractRealTreeTests walks Commerce's namespaces as adapter roots
with the existing adapter scanner and fails on any Egg Operations type
outside owners.EggOperations.contract. It is the first peer check #1023
asks for. Re-adding IEggLotRepository to ConfirmSaleHandler turns it red
while every other architecture test stays green.

Refs #854, #1023
…round

The Farm -> Commerce and Access -> Commerce reasons, and three Commerce
foreign-key reasons, still named repositories the handlers no longer
take. The decision record and AGENTS.md describe the transaction binding,
the peer check and the stricter atomicity test.

Refs #854
@mforce

mforce commented Oct 3, 2026

Copy link
Copy Markdown
Owner Author

Review round 1, fixed at bc3eb12e. One round for both reviews (Astra comment, Opus comment). origin/main (b88ec0eb, #1029, test-only) is merged in with a merge commit, 9c72c162. No force-push.

Item Commit Change Evidence
Astra P2: a reservation stayed usable after its transaction e797cd85 LockForSaleAsync and RestoreAsync refuse with no current transaction. A reservation stores the id of the transaction that locked it, and Plan and DrawAsync throw when another transaction, or none, is current. The id comes through a new Platform port, ICurrentTransaction (Guid? Id, read from Database.CurrentTransaction), so no persistence type reaches Application. New EggStockTransactionTests (real Postgres, 3 cases): your escape (keep the reservation past ExecuteInTransactionAsync's commit, then plan and draw), the same inside a later transaction, and the port with no transaction. Each requires InvalidOperationException and unchanged stock (30 available, no Sale movement). With the reservation check dropped, the first two fail (Assert.Throws() Failure: No exception was thrown). With the port allowed to run without a transaction, the third fails the same way. Reverted.
Opus F1: the rollback test failed only when both rules broke e797cd85 Your prototype, with the context passed into the decorators instead of a static field. At each fault it checks CurrentTransaction is not null and that no tracked EggLot is Unchanged. Every locked lot in the fixture is drawn, so an Unchanged lot is a draw already saved. Clean: 4/4 green. Save after each draw, transaction kept: 4/4 red, a drawn lot was already saved before the fault. Confirm without its transaction: 4/4 red, now first at the port's new check (IEggStock runs only inside a transaction). With the port's checks also off: 4/4 red at the test's own the confirm runs outside a transaction. All reverted.
Opus F2: nothing enforced "Commerce never sees EggLot" 1e7f511b PeerContractRealTreeTests (43 lines, no new scanner). It loads the ledger, sets adapterRoots to Commerce's namespaces, runs the existing AdapterReachScanner, and fails on any EggOperations contract bypass. A positive control requires it to see ConfirmSaleHandler.ctor reach IEggStock. This is the first peer check #1023 asks for. Like the adapter check, it reads signatures and service resolutions, not method bodies; the decision record says so. Your mutation D (IEggLotRepository eggLots added to ConfirmSaleHandler and planned over): this test fails with ConfirmSaleHandler.ctor through Cluckwork.Application.Features.EggLots.IEggLotRepository at …ConfirmSaleHandler.cs:18, and the other 338 architecture tests stay green. Reverted.
Opus F3: two ledger reasons named IEggUnitConversionRepository bc3eb12e Access → Commerce and Farm → Commerce now name IEggUnitConversionLookup. Three Commerce foreign-key reasons had the same staleness (IEggGradeRepository, IEggLotRepository) and now name IEggGradeLookup and IEggStock. module-ledger.json names no Egg Operations repository for Commerce. The matrix regenerates byte-identical.
Opus F4: Plan was called pure e797cd85, bc3eb12e The interface comment and the decision record now say Plan reads the locked lots' current availability and changes nothing, and that a plan made after a draw sees less. AGENTS.md no longer says "pure". Wording only.

Behaviour is unchanged. I re-ran the SQL-trace script on bc3eb12e against main d6dbd47d; #1029 changed no source. All 54 files are byte-identical: confirm success, void success, short confirm and the payment-gated void (SQL and transaction events), both currency-probe scenarios, and 24 Commerce routes (SQL and normalised response bodies). The new transaction check reads Database.CurrentTransaction and issues no SQL.

Tests run locally on bc3eb12e:

  • Application: 682/682. That is 681 plus PeerContractRealTreeTests; the matrix, ledger, contract and compatibility guards are included.
  • Integration, targeted: 407/407. This covers the 384-test stock, sales, seed and currency set, plus EggStockTransactionTests, ConfirmSaleAtomicityTests, TransientDbResilienceTests and RetryBoundaryTests. SchemaDocsTests also passes.
  • The build is clean with warnings as errors, which enforces the build: enforce file-scoped namespaces and using placement at build time #985 style gate. An IDE0005 build finds no unused usings in this round's files.

Not done, on purpose. I left Opus's nits as they are: duplicate grade-read bodies, DrawAsync not refusing a repeated draw, CommerceModule's construction cost, and no check that every source is registered. None was in scope for this round. The full integration suite runs in CI.

@mforce

mforce commented Oct 3, 2026

Copy link
Copy Markdown
Owner Author

Review of record: Codex gpt-6-astra (xhigh), round 2, at head bc3eb12e. Posted by the coordinator. Evidence paths named below are local to the review host.

PR #1030, round 2, Astra review

Reviewed bc3eb12e9e4ca6b0e18bac8eb93d721df74673f6, scoped to the fixes since 4036702d. Reviewed independently, without delegation or contact with the other reviewer. Did not wait for CI.

The original P2 escape is fixed. One further P2 contract defect is confirmed below. No current product defect or legitimate-confirm regression was demonstrated.

  1. P2, CONFIRMED. Comparing only the outer transaction ID accepts a reservation after savepoint rollback has released its row locks.

    Location: src/Cluckwork.Application/Features/EggLots/EggStock.cs:86; the identity comes from src/Cluckwork.Infrastructure/Persistence/CurrentTransaction.cs:7.

    Concrete scenario: begin a transaction, create savepoint S, call LockForSaleAsync, then roll back to S. PostgreSQL releases the locks acquired after S, but EF's current transaction and TransactionId remain unchanged. Both Plan and DrawAsync therefore pass EnsureLocked, even though the reservation's lots are no longer locked. The caller can save a draw using that expired lock protection.

    Evidence: the real-Postgres diagnostic Scratch1030Round2Tests.RollbackToSavepointBeforeLock_ReleasesLocksButReservationStillDraws checks all of these facts. A second connection's FOR UPDATE NOWAIT fails before the rollback and succeeds afterward; ICurrentTransaction.Id stays equal; planning and drawing still succeed; after commit, a fresh context sees availability reduced from 30 to 20 and one Sale movement. No production source mutation was needed. PostgreSQL explicitly documents that rollback releases locks acquired after the savepoint, including row locks. PostgreSQL locking documentation.

    Product impact: not a demonstrated current product defect. I found no explicit savepoint creation or rollback in src/. Confirm's normal EF savepoint occurs after its lot locks, so that normal sequence does not reproduce this escape. The defect is in the new lock-lifetime guarantee under the savepoint scenario requested for this review. The original post-commit escape is closed; this is a separate way to release the locks without changing the checked ID.

    Required change: account for rollback that releases the reservation's locks, and reject continued use of that reservation. This needs a lifecycle signal beyond the outer transaction ID, or an explicit, enforced restriction on savepoint recovery while reservations exist. Preserve the distinction between rollback past lock acquisition and savepoints created after the locks. Add the demonstrated case to the real transaction tests.

The original escape now fails for the intended reason. I reran the exact round-one diagnostic, which previously planned and persisted a draw after commit. It now throws InvalidOperationException from EnsureLocked at Plan, with This reservation's transaction has ended, so its lots are no longer locked. The new committed tests independently check direct DrawAsync rejection, a later transaction, and lock/restore with no transaction, including unchanged persisted stock.

The additional binding probes established:

Probe Result
Nested ExecuteInTransactionAsync Joins the original transaction; reservation remains valid before and after the inner save
Savepoint created after lot locking, followed by rollback to that savepoint Lot lock remains held; reservation remains valid
Async continuation through Task.Yield Same transaction binding; valid draw persists
Different AppDbContext constructed within the same service scope Its transaction does not authorize the stock port bound to the registered context
Explicit execution-strategy replay after a transient timeout New transaction ID; old reservation rejected; newly acquired reservation works
SingleAttemptExecution through the real unit of work, timeout after locking Exactly one attempt; transaction ends; retained reservation is rejected

AddDbContext<AppDbContext> and the repository, unit-of-work and ICurrentTransaction registrations are scoped. All those registered services receive the same context. CurrentTransaction.Id reads that context dynamically; it is not a captured constructor-time value or ambient task variable. No context split was found in the real composition.

The normal confirm transaction is not automatically replayed. SingleAttemptExecution captures failures inside the EF strategy and rethrows after it returns, preserving #269. My explicit replay probe intentionally used the lower-level retrying strategy to test a new transaction; it does not describe production confirm behavior. Freshly acquired reservations work on that new attempt. Both RetryBoundaryTests and TransientDbResilienceTests passed, including the bootstrap retry-boundary case selected by the filter.

The revised atomicity tests now catch each single regression. An added SaveChangesAsync inside DrawAsync, with the transaction intact, failed all four cases with a drawn lot was already saved before the fault. Removing only the confirm transaction failed all four at the port's new refusal. Disabling that refusal as well still failed all four at the test's own the confirm runs outside a transaction assertion. All mutations compiled and were reverted.

The peer guard works within its stated scope. Independent mutations adding IEggLotRepository to the confirm constructor, a nested IReadOnlyDictionary<Guid, IReadOnlyList<EggLot>> parameter, and GetService(typeof(IEggLotRepository)) each failed with the offending symbol and type. A control using contract types plus the intentionally retained Farm/Access dependencies passed. No false positive was observed. A method merely returning IEggLotRepository stayed green: the reused scanner reads parameters and service resolutions, not all type references. The decision record states that narrower scope; the guard should not be described as complete enforcement of every Commerce reference.

No persistence type crosses the actual ICurrentTransaction or IEggStock signatures. The real seam and module-contract guards passed. Adding an IQueryable<Guid> property to ICurrentTransaction made the seam guard fail with that exact interface/member, then was reverted.

Confirm and void handler bodies are unchanged by this round. The new transaction checks issue no SQL. I reran the three trace cases on this head and compared all 54 outputs against my independently recorded d6dbd47d baseline from round one. All recorded SQL, transaction events and normalized response bodies matched. As before, the two currency-probe headers require normalization of their generated farm IDs; SQL parameter values are not recorded by this script. The ledger reason and Plan comment corrections match the code.

Verification: 51 targeted integration cases passed, including transaction, atomicity, lock-order, allocation-policy, confirm/void race and retry suites. Ten targeted Application guards passed. Five additional binding diagnostics passed, one deliberately demonstrating the savepoint defect. The old escape diagnostic failed as expected at the new refusal; the other two round-one diagnostics and all three trace cases passed. After restoring mutations, the ten Application guards and seven transaction/atomicity cases passed again. The final solution build had zero warnings and errors. No full integration suite was run.

Evidence: 1030-bc3eb12e-astra-evidence/ beside this report contains diagnostic source, mutation scripts/logs, TRX results and the new traces. The assigned checkout is clean at bc3eb12e; all temporary test files and mutations were removed. No commits, pushes or GitHub comments were made.

Nits: none.

Verdict: request changes for savepoint invalidation; the original escape is fixed and no current product regression was demonstrated.

…llback

The reservation check compares transaction ids only. Rolling back to a
savepoint taken before LockForSaleAsync releases its row locks while the
transaction id stays the same. Nothing in src/ creates such a savepoint;
the owner chose to record the limit (PR #1030, review round 2).

Refs #854
@mforce

mforce commented Oct 3, 2026

Copy link
Copy Markdown
Owner Author

Review round 2, documented at ae905442. This replies to Astra's round 2 (comment). The commit changes documentation and comments only; no code changes.

The savepoint edge is real. Rolling back to a savepoint taken before LockForSaleAsync releases the reservation's row locks, but the transaction id stays the same, so the reservation would still plan and draw. The owner chose to record this limit rather than track savepoints:

  • IEggStockReservation's comment (IEggStock.cs) now says the check compares transaction ids only. A reservation must not be used after a rollback to a savepoint taken before it was created, because PostgreSQL releases its locks while the id stays the same.
  • docs/decisions/854-commerce-contract.md says the same and records that the owner chose to document it (PR refactor(sales): put Commerce behind a contract and reach egg stock through IEggStock #1030, review round 2).

Both say that nothing in src/ creates or rolls back to such a savepoint. grep -rn "Savepoint\|SAVEPOINT" src --include=*.cs finds nothing. The only savepoint is the one EF takes inside SaveChanges, after the locks, as the SQL trace's TX SAVEPOINT line shows.

Checks on ae905442: the solution builds, and the Application documentation guards (20) and SchemaDocsTests (4) pass.

The review loop was stopped deliberately by the owner after two consecutive rounds that found no product defect (rounds 1 and 2 found only seam-contract and test gaps). No round 3 will be triggered.

@mforce

mforce commented Oct 3, 2026

Copy link
Copy Markdown
Owner Author

Independent review: Claude Fable 5.1 (xhigh), at head ae905442. Third reviewer, requested by the owner; posted by the coordinator. Evidence paths named below are local to the review host.

PR #1030 review: Commerce contract and the IEggStock seam (Fable, independent third review)

  • Head: ae905442cf1b7d5c441199fad98db1bdbea76cb2. Base: main b88ec0eb.
  • Checkout: detached worktree /home/mforce/.cluckwork-slices/review-1030-fable, removed after the review.
  • Method. I read the full diff (49 files) and compared the confirm and void handlers line by line against main before reading the PR body, the two Codex Astra rounds and the Opus round. I then built the Application and Integration test projects, ran the new and changed test classes on the clean head, and ran four mutations none of the earlier rounds tried. Every mutation was reverted; git status was clean before removal. I did not run the full integration suite.
  • CI on the head: every check green (domain, application, apphost, integration, build, web, both image legs, CodeQL, GitGuardian).

Verdict on the core question

No product defect. Confirm and void do the same work, in the same order, against the same rows, inside the same transaction, as on main.

  • Transaction boundary. Both handlers still run one ExecuteInTransactionAsync. UnitOfWork saves once after the lambda returns true. HTTP calls join IdempotencyMiddleware's transaction; direct calls (seeders, tests) own one through SingleAttemptExecution. The port opens no transaction and never saves (EggStock.cs: no SaveChanges, no BeginTransaction, no execution strategy).
  • Lock count, order and scope. One FOR UPDATE per confirm, farm-wide, every grade on the order, ORDER BY "ProductionDate", "Id". The SQL never left EggLotRepository.cs:35-60. LockForSaleAsync is called once (ConfirmSaleHandler.cs:217), after the Account FOR SHARE, the order FOR UPDATE, the fresh role read, the ceiling check and the assignment read, exactly where GetAvailableFifoLockedAsync was called before.
  • Two Configurable Worker sales allocation scope by flock assignment #612 passes over the same locked rows. stock.Plan(demand, assignedFlockIds) then stock.Plan(demand) (ConfirmSaleHandler.cs:229,238). Both run SaleAllocationPlanner.Plan over the one locked list; the assigned filter is the same Contains(l.FlockId) the handler used inline. No second query.
  • Allocate and Restore. DrawAsync calls lot.Allocate(draw.Quantity, saleDate) on the same tracked instance the plan read, with the allocationDate the handler passed to the lock; a refusal throws the same InvalidOperationException text. RestoreAsync iterates the locked lots in canonical order and calls lot.Restore as the inline loop did.
  • The allocation object created before Allocate. SalesOrderAllocation.Create is pure (positivity check, Guid.NewGuid(), no clock, no tracking). Both objects stay in memory until allocations.AddRangeAsync after the loop, which is where they were added before. Change-tracker insertion order is unchanged: movements added per draw, allocations after the loop.
  • Movement rows. Sale: -quantity, reference type SalesOrderAllocation, the allocation id. Void: +quantity, reference type SalesOrder, the order id, the reason. Same arguments to EggInventoryMovement.Create as on main.
  • Payment gate, Version, 409s, provenance release. Untouched lines in VoidSaleHandler; the only edit replaces the inline lock-and-restore loop with eggStock.RestoreAsync (VoidSaleHandler.cs:89) and maps its Result error through unchanged.
  • Retry boundary (Add transient-DB resilience (EnableRetryOnFailure) on the request path #269). ICurrentTransaction cannot refuse a legitimate confirm. SingleAttemptExecution never replays the lambda, EF suspends nested retries inside the strategy, and the joined path uses the middleware's single transaction object, whose TransactionId is fixed at BeginTransactionAsync. The only replaying strategy in src/ is DemoDataSeeder's cleanup block, which runs no confirm. A reservation is a local in the lambda, so even an outer replay would mint a fresh one.
  • Failure paths. Insufficient stock on any line is decided by the whole-order plan before any draw. An exception mid-draw propagates out of the lambda, and the owned scope disposes the transaction without commit; the joined path's middleware rolls back and clears the tracker, as before. Two confirms queue on the lot locks and the loser re-plans over the committed rows. A void racing a confirm queues on the same canonical order. A cancelled token mid-draw is an exception like any other. Status codes are unchanged because every refusal Error code and description string is byte-identical.
  • Data. No persisted row changes field, order or timestamp. The PR's SQL trace claim is consistent with the reading; I did not rerun the script.
  • DI. ICurrentTransaction, IEggStock, both repositories and IUnitOfWork are scoped on the one AddDbContext<AppDbContext> (CluckworkFeatureServiceCollectionExtensions.cs:106,382). The only hand-built contexts in src/ are ExportQueries' read-only snapshot and the design-time factory; neither feeds the port. IEnumerable<ICurrencyBoundRowSource> resolves in registration order, which is the old probe's order; the answer is an OR over side-effect-free reads, so order can only change the query count.
  • Currency probe. Seven predicates verbatim, same order, same short-circuit, same scoped context.

Findings

Neither finding is a product defect. Severity is P3 for both.

F1: the sale-to-line provenance id is unpinned by any test (test coverage, CONFIRMED)

  • Location. src/Cluckwork.Application/Features/EggLots/SaleAllocationPlanner.cs:52 (PlannedEggLotDraw(item.LineId, …)), consumed at ConfirmSaleHandler.cs:292 (SalesOrderAllocation.Create(…, draw.LineId, …)).
  • Defect. This PR renamed the draw's SalesOrderItemId to LineId and moved the planner across a module boundary, and no test anywhere asserts which line an allocation row belongs to. SalesOrderAllocation.SalesOrderItemId is product data: the Admin CSV export writes it (ExportQueries.cs:193-196, column salesOrderItemId), and it is the sale-to-lot traceability chain spec §9.6 names.
  • Failure scenario. A later edit stamps every draw with the first line's id. The FK to SalesOrderItems still holds, confirm, void and FIFO still work, the export carries wrong line ids for every multi-line order, and nothing goes red.
  • Evidence. Mutation A: item.LineId → items[0].LineId in the planner. Built clean. SaleAllocationPlannerTests, CommerceModuleTests, EggOperationsModuleTests: 21/21 green. Integration VoidSaleTests, SaleAllocationPolicyTests, SalesOrderAuditPayloadTests, EggLedgerTests, every *Export* class and ConfirmSaleAtomicityTests: 74/74 green. grep -rn SalesOrderItemId tests finds no assertion on an allocation row; SaleAllocationPlannerTests.RepeatedGrade_AcrossTwoLines_SharesTheSameLotWithoutDoubleCounting (line 41) checks quantities only.
  • Recommendation. Add Assert.Equal(order[0].LineId, plan.Draws[0].LineId) and order[1].LineId for the second draw in that planner test. It is a two-line change and makes the rename this PR performed a guarded one.

F2: AGENTS.md says the peer guard "reads signatures"; it reads parameter types and service resolutions (doc wording, PLAUSIBLE)

  • Location. AGENTS.md:84, the [C] #514 slice 12: Commerce contract, consuming the stock seam #854 sentence "though it reads signatures, not method bodies". The decision record states the narrower truth at docs/decisions/854-commerce-contract.md:124.
  • Defect. A return type is part of a signature, and AdapterReachScanner.AdapterTypes (AdapterReachScanner.cs:351-361) collects ParameterSyntax types and resolver-call type arguments only. A Commerce method returning IEggLotRepository or EggLot stays green.
  • Evidence. Reading of the scanner; Astra's round-2 probe (a method returning IEggLotRepository stayed green) agrees. Not reproduced by me, hence PLAUSIBLE.
  • Recommendation. Replace "signatures" with "parameter types and service resolutions" in AGENTS.md so the two documents agree, per the rule that successive corrections keep drifting when copies differ.

Already raised in earlier rounds, not re-raised here

  • Opus's coverage note that ConfirmSaleAtomicityTests exercises the owned-transaction path only. True; the joined path is covered indirectly by the HTTP race tests.
  • The savepoint limit (owner-accepted, documented). Nothing in src/ creates one before the lock.
  • DrawAsync accepting any draw and CommerceModule's construction cost (Opus nits, owner left as is).

Nits

  • EggOperationsModule.GetGradeAsync and EggGradeLookup.GetAsync have identical bodies. One could delegate to the other. Already noted by Opus.
  • CommerceModuleTests pins that an unmapped product reports Guid.Empty, not null, for EggGradeId (line 91-101). That is faithful to main's Dictionary<Guid,Guid>.GetValueOrDefault, so the PR is right to keep it, but the pinned value is an accident of the old code rather than a design. Worth a follow-up, not a change here.
  • services.AddScoped<ICurrencyBoundRowSource, SalesOrderRepository>() and the ISalesOrderRepository registration yield two repository instances per scope. Harmless because the repositories are stateless over the shared context.

Reproduced and verified

Check Result
Build: Integration and Application test projects, warnings as errors (#985 style gate) clean, 0 warnings
Clean head: EggStockTransactionTests (3), ConfirmSaleAtomicityTests (4), EggLotLockOrderTests (6) 13/13 green
Clean head: PeerContractRealTreeTests, SaleAllocationPlannerTests, CommerceModuleTests 13/13 green
Mutation A (new): planner stamps every draw with the first line's id 95/95 green (F1)
Mutation B (new): IEggLotRepository re-injected into ConfirmSaleHandler behind a using alias (using Lots = …IEggLotRepository; Lots lots) PeerContractRealTreeTests red: ConfirmSaleHandler.ctor through Cluckwork.Application.Features.EggLots.IEggLotRepository at …/ConfirmSaleHandler.cs:19. The scanner expands aliases.
Mutation C (new): EnsureLocked weakened from transaction.Id != lockedIn to transaction.Id is null EggStockTransactionTests 1 red / 2 green: Reservation_InsideALaterTransaction_RefusesToPlanOrDraw. The test pins the transaction's identity, not its presence. (A first attempt left lockedIn unread and failed the build on CS9113; the run was repeated with the parameter consumed.)
Mutation D (new): confirm's audit.WriteAsync moved after ExecuteInTransactionAsync ConfirmSaleAtomicityTests 1 red / 3 green: AfterOrderConfirmation, at the test's own "runs outside a transaction" check. #494's same-transaction rule is enforced.

All four mutations were reverted; git status --short printed nothing before the worktree was removed.

Not reproduced: the 54-file SQL trace (script not committed; Astra re-ran it twice), the two-grade lock mutation (run by the PR, Opus and Astra, all red), the EF model digest.

Summary

  • Product defects: none. The seam is a faithful extraction; every behaviour difference I looked for reduced to the same SQL, the same tracked instances and the same change-tracker order.
  • Findings: 2 × P3. F1 is a real coverage hole exposed by this PR's own rename (CONFIRMED by a 95-test-green mutation); F2 is a one-word doc drift.
  • Earlier reviews. I agree with Opus and both Astra rounds on the handler equivalence. The reservation-lifetime fixes hold under a weaker-check mutation they did not run.

Verdict: merge. F1 is a two-line test addition that could ride along or follow.

SaleAllocationLineTests confirms a two-line order and requires each
allocation row to name the line whose grade it drew; nothing pinned it,
and the admin export carries it. AGENTS.md and the decision record now say
the peer check reads parameter types and service resolutions, not return
types or method bodies.

Refs #854
@mforce

mforce commented Oct 3, 2026

Copy link
Copy Markdown
Owner Author

Fable review, both items fixed at 04222ba6. No new review round.

  • F1 (allocation rows not pinned to their line). New SaleAllocationLineTests confirms a two-line order (Large 10, Medium 4) and requires each allocation row to name the line whose grade it drew. Your mutation, the planner stamping every draw with items[0].LineId, fails it 3 of 3. So does the handler-side version, SalesOrderAllocation.Create(…, order.Items[0].Id, …), 3 of 3. In both, the 4-egg Medium draw names the Large line. Both mutations reverted. I tested persisted rows rather than the planner, so the handler's mapping is pinned too.
  • F2 (guard wording). AGENTS.md and the decision record now both say PeerContractRealTreeTests reads only parameter types and service resolutions, not return types or method bodies. I checked the limit: a ConfirmSaleHandler method returning IEggLotRepository keeps it green.

Checks: the solution builds clean. The new test passes, as do the documentation guards (20) and SchemaDocsTests (4). No production code changed in this commit.

@mforce
mforce merged commit 7bb45cc into main Oct 3, 2026
19 checks passed
@mforce
mforce deleted the refactor/854-commerce-contract branch October 3, 2026 07:03
mforce added a commit that referenced this pull request Oct 3, 2026
…h-verifier

No conflicts. The regenerated coupling matrix equals the auto-merged one.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[C] #514 slice 12: Commerce contract, consuming the stock seam

1 participant